"""A move encapsulates a player's action for a single move in a game."""

__author__ = "Gabriel Taubman (gtaubman@mac.com)"

import unittest
from constants import BLANK_TILE

class InvalidMoveError(Exception):
  """An exception to be raised when an invalid tile is added to a move."""
  def __init__(self, reason):
    self.reason = reason

  def __str__(self):
    return self.reason

class Move:
  """A Move encapsulates a player's actions for a single turn.

  A Move can signal one of four actions:
   - Playing letters with AddLetterAtPosition().
   - Exchanging letters with AddLettersToExchange().
   - Passing for this turn with Pass().
   - Finishing the game with DoneWithGame().

  A Move use for playing tiles enforces several restrictions on the tiles being
  added to it:
   - All letters must be alphabetical.  No umlauts or numbers.
   - Letters may not be placed on top of previously added letters.
   - The position must be a (x,y) tuple.
   - The first letter added to the Move may go anywhere.
   - The next letter added to the move has to share either an X or Y axis with
     the first tile.
   - Any subsequent letters added to the move must share the same axis as the
     first two letter.
  These are not the only restrictions on a move, but they are the only ones
  enforced by the Move class.  The rest require a board state and are thus
  enforced by the Board class in ScoreForMove().

  When letters are played, the concept of "forwards" and "backwards" is
  introduced.  These map to the order the letters are read in.  For horizontal
  moves, reading forward is left to right (negative to positive in the X axis).
  For vertical moves, forward is top to bottom (positive to negative in the Y
  axis).

  Examples:
    Adding a three letter horizontal word.
    >>> m = Move()
    >>> m.AddLetterAtPosition("G", (0,0))
    >>> m.AddLetterAtPosition("A", (1,0))
    >>> m.AddLetterAtPosition("B", (2,0))
    >>> m.AddLetterAtPosition("E", (3,0))
    >>> m.Word()
    'GABE'

    Adding a three letter vertical word.
    >>> m = Move()
    >>> m.AddLetterAtPosition("G", (0,0))
    >>> m.AddLetterAtPosition("A", (0,1))
    >>> m.AddLetterAtPosition("B", (0,2))
    >>> m.AddLetterAtPosition("E", (0,3))
    >>> m.Word()
    'EBAG'

    Adding a word that contains spaces is fine.
    >>> m = Move()
    >>> m.AddLetterAtPosition("G", (0,15))
    >>> m.AddLetterAtPosition("A", (0,10))
    >>> m.AddLetterAtPosition("B", (0,5))
    >>> m.AddLetterAtPosition("E", (0,0))
    >>> m.Word()
    'GABE'
  """

  # Directions for words.
  NO_DIRECTION = 0
  HORIZONTAL = 1
  VERTICAL = 2

  # Move Types:
  PLAY_TILES = 0
  EXCHANGE_TILES = 1
  PASS = 2
  DONE = 3
  NONE = 4

  def __init__(self):
    self.Clear()

  def Clear(self):
    """Resets this move to the default state."""
    self.tiles = {}
    self.direction = Move.NO_DIRECTION
    self.valid = True 
    self.type = Move.NONE

  def NumTiles(self):
    """Returns the number of tiles in this move.

    If this move is plaing tiles, it will return how many are being placed.  If
    this move is exchanging tiles it will return how many are being requested to
    be exchanged.
    """
    return len(self.tiles)

  def Tiles(self):
    """Returns a list of the (position, letter) tuples for this move."""
    return self.tiles.items()

  def IterTiles(self):
    """Returns an iterator over the (position, letter) tuples for this move."""
    return self.tiles.iteritems()

  def Direction(self):
    """Returns one of Move.{NO_DIRECTION,HORIZONTAL,VERTICAL}."""
    return self.direction

  def OppositeDirection(self):
    """Returns the opposite direction of the direction for this move.

    For VERTICAL and NO_DIRECTION moves, returns HORIZONTAL.
    For HORIZONTAL moves, returns VERTICAL.
    """
    if (self.Direction() == Move.VERTICAL or
        self.Direction() == Move.NO_DIRECTION):
      return Move.HORIZONTAL
    return Move.VERTICAL

  def Valid(self):
    """Returns whether or not this move is valid.
    
    A Move can become invalid after raising an exception.
    """
    return self.valid

  def IterPositions(self):
    """Returns an iterator over (x,y) position pairs."""
    return self.tiles.iterkeys()

  def UsesPosition(self, position):
    """Returns whether or not this move uses the specified (x,y) position."""
    return self.tiles.has_key(tuple(position))

  def LetterAtPosition(self, position):
    """Returns the letter at the specified (x,y) position."""
    return self.tiles[tuple(position)]

  def LeftmostPosition(self):
    """Returns the left-most position in this move as an (x,y) tuple."""
    tiles = self._SortedTiles()
    return tiles[0][0]

  def RightmostPosition(self):
    """Returns the right-most position in this move as an (x,y) tuple."""
    tiles = self._SortedTiles()
    return tiles[-1][0]

  def TopmostPosition(self):
    """Returns the top-most position in this move as an (x,y) tuple."""
    return self.LeftmostPosition()

  def BottommostPosition(self):
    """Returns the bottom-most position in this move as an (x,y) tuple."""
    return self.RightmostPosition()

  def Position(self):
    """Returns the position of a tile from the move.
    
    If this move has more than one tile in it, this will return one tile.
    """
    return self.tiles.keys()[0]

  def DoneWithGame(self):
    """Signals that this move is the last from a player.

    Raises an InvalidMoveError if this move already performed another action.
    """
    # Make sure the move type hasn't already been established.
    if self.type != Move.NONE and self.type != Move.DONE:
      raise InvalidMoveError("A move must have only one type.")
    self.type = Move.DONE
    
  def Pass(self):
    """Signals that a player wishes to pass this turn.

    Raises an InvalidMoveError if this move already performed another action.
    """
    if self.type != Move.NONE and self.type != Move.PASS:
      raise InvalidMoveError("A move must have only one type.")
    self.type = Move.PASS

  def AddLettersToExchange(self, letters):
    """Signals that a player wants to exchange a tile.
    
    If a player wishes to exchange one or more of their tiles for their move,
    the tiles can be added via this function.
    Raises an InvalidMoveError if this move already performed another action.

    Args:
      letters: A string of 1 or more capital letters to exchange.
    """
    # Make sure the move type hasn't already been established.
    if self.type != Move.NONE and self.type != Move.EXCHANGE_TILES:
      raise InvalidMoveError("A move must have only one type.")
    self.type = Move.EXCHANGE_TILES

    for letter in  letters:
      num_tiles = len(self.tiles)
      self.tiles[(num_tiles, num_tiles)] = letter

  def AddLetterAtPosition(self, letter, position):
    """Signals that a player wishes to play tiles.

    Adds a letter at the specified position, and performs some checks.
    Raises an InvalidMoveError if this move was already of another type.
    See comments for the Move class for examples of how to use this.

    Args:
      letter: A single letter alphabetical string.
      position: An (x,y) tuple.

    """
    if self.type != Move.NONE and self.type != Move.PLAY_TILES:
      raise InvalidMoveError("A move must have only one type.")
    self.type = Move.PLAY_TILES 

    if type(position) != tuple or len(position) != 2:
      self.valid = False
      raise TypeError("Position must be a (x,y) pair. Not %s." % repr(position))

    if len(letter) != 1 or (not letter.isalpha() and letter != BLANK_TILE):
      self.valid = False
      raise TypeError("Letter must be a letter. Not %s." % repr(letter))

    # They can't add a piece on top of a previous piece.
    if self.tiles.has_key(position):
      self.valid = False
      raise InvalidMoveError("There's already a piece at %s in this move." %
                             repr(position))

    # Depending on how many tiles have been added to this move previously, a
    # few different things can go wrong.
    # - If there are 0 tiles previously added, we don't care about anything.
    # - If there is 1 tile previously added, the new letter must line up with
    #   either the X or Y axis of the previously added tile.
    # - If there are 2 or more previously added tiles, the new letter must be
    #   in the same DIRECTION as the previous ones.
    if len(self.tiles) == 0:
      # We had better have NO_DIRECTION or else something's gone wrong.
      if self.direction != Move.NO_DIRECTION:
        self.valid = False
        raise InvalidMoveError("Invalid Direction: %d" % self.direction)
    elif len(self.tiles) == 1:
      # We had better have NO_DIRECTION or else something's gone wrong.
      if self.direction != Move.NO_DIRECTION:
        self.valid = False
        raise InvalidMoveError("Invalid Direction: %d" % self.direction)

      # Either the X or Y component must be equal to our previous tile.
      pos = self.tiles.keys()[0]
      if pos[0] == position[0]:
        self.direction = Move.VERTICAL
      elif pos[1] == position[1]:
        self.direction = Move.HORIZONTAL
      else:
        self.valid = False
        raise InvalidMoveError(
            "Unable to place a piece at %s because it " % repr(position) +
            "doesn't line up with previous pieces: %s" % repr(self.tiles))
    else:
      # If we don't already have a direction then something's been broken.
      if self.direction != Move.HORIZONTAL and self.direction != Move.VERTICAL:
        self.valid = False
        raise InvalidMoveError("Invalid Direction: %d" % self.direction)

      # Check that this letter lines up with our direction.
      if (self.direction == Move.HORIZONTAL and
          position[1] != self.tiles.keys()[0][1] or
          self.direction == Move.VERTICAL and
          position[0] != self.tiles.keys()[0][0]):
        self.valid = False
        raise InvalidMoveError(
            "Unable to place a piece at %s because it " % repr(position) +
            "doesn't line up with previous pieces: %s" % repr(self.tiles))

    # We survived the gauntlet.  It's safe to add this letter to the move.
    self.tiles[position] = letter

  def Word(self):
    """Returns a string of the letters in the order they should be read.."""
    if not self.tiles:
      return None

    tiles = self._SortedTiles()
    return "".join([item[1] for item in tiles])

  def _SortedTiles(self):
    """Returns a list of (pos,letter) of this move's tiles sorted in word order.

    If it is a horizontal move, it returns them sorted left to right.  If it is
    vertical it returns them sorted top to bottom.
    """
    if self.direction == Move.HORIZONTAL:
      tiles = [(pos[0], (pos, letter)) for pos,letter in self.tiles.iteritems()]
      tiles.sort()
    elif self.direction == Move.VERTICAL:
      tiles = [(pos[1], (pos, letter)) for pos,letter in self.tiles.iteritems()]
      tiles.sort(reverse=True)
    elif self.direction == Move.NO_DIRECTION:
      tiles = [(pos[0], (pos, letter)) for pos,letter in self.tiles.iteritems()]
    else:
      return None
    return [item[1] for item in tiles]

  def __iter__(self):
    return self.tiles.iteritems()

  def __str__(self):
    return str(self.tiles)

class MoveTest(unittest.TestCase):
  def testAddingSinglePiece(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    self.assert_(m.Valid())
    self.assert_(m.Direction() == Move.NO_DIRECTION)

  def testNumTiles(self):
    m = Move()
    m.AddLetterAtPosition("A", (0,0))
    self.assert_(m.NumTiles() == 1)

  def testAddingSingleNonAlphabeticalPiece(self):
    m = Move()
    self.assertRaises(TypeError, m.AddLetterAtPosition, "1", (0,0))
    self.assert_(m.Direction() == Move.NO_DIRECTION)
    self.assert_(not m.Valid())

  def testAddingSinglePieceWithInvalidLocationDimensions(self):
    m = Move()
    self.assertRaises(TypeError, m.AddLetterAtPosition, "1", (0))
    self.assert_(m.Direction() == Move.NO_DIRECTION)
    self.assert_(not m.Valid())

  def testAddingSinglePieceWithInvalidLocationType(self):
    m = Move()
    self.assertRaises(TypeError, m.AddLetterAtPosition, "1", [0, 1])
    self.assert_(not m.Valid())
    self.assertRaises(TypeError, m.AddLetterAtPosition, "1", "hi")
    self.assert_(not m.Valid())

  def testAddingMultiplePiecesOnTopOfOneAnother(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    self.assertRaises(InvalidMoveError, m.AddLetterAtPosition, "b", (0,0))
    self.assert_(m.Direction() == Move.NO_DIRECTION)
    self.assert_(not m.Valid())

  def testAddingTwoLettersInVerticalAlignment(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    m.AddLetterAtPosition("b", (0,1))
    self.assert_(m.Direction() == Move.VERTICAL)
    self.assert_(m.OppositeDirection() == Move.HORIZONTAL)
    self.assert_(m.Valid())

  def testAddingTwoLettersInHorizontalAlignment(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    m.AddLetterAtPosition("b", (1,0))
    self.assert_(m.Direction() == Move.HORIZONTAL)
    self.assert_(m.OppositeDirection() == Move.VERTICAL)
    self.assert_(m.Valid())

  def testTwoLettersOutOfAlignment(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    self.assertRaises(InvalidMoveError, m.AddLetterAtPosition, "b", (1,1))
    self.assert_(not m.Valid())

  def testAddingThreeLettersInVerticalAlignment(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    m.AddLetterAtPosition("b", (0,1))
    m.AddLetterAtPosition("c", (0,2))
    self.assert_(m.Direction() == Move.VERTICAL)
    self.assert_(m.OppositeDirection() == Move.HORIZONTAL)
    self.assert_(m.Valid())

  def testAddingThreeLettersOutOfAlignmentVertically(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    m.AddLetterAtPosition("b", (0,1))
    self.assertRaises(InvalidMoveError, m.AddLetterAtPosition, "c", (1,2))
    self.assert_(not m.Valid())

  def testAddingThreeLettersInHorizontalAlignment(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    m.AddLetterAtPosition("b", (1,0))
    m.AddLetterAtPosition("c", (2,0))
    self.assert_(m.Direction() == Move.HORIZONTAL)
    self.assert_(m.OppositeDirection() == Move.VERTICAL)
    self.assert_(m.Valid())

  def testAddingThreeLettersOutOfAlignmentVertically(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,0))
    m.AddLetterAtPosition("b", (1,0))
    self.assertRaises(InvalidMoveError, m.AddLetterAtPosition, "c", (2,1))
    self.assert_(not m.Valid())

  def testWordComesOutInCorrectOrder(self):
    m = Move()
    m.AddLetterAtPosition("a", (0,1))
    m.AddLetterAtPosition("b", (0,2))
    m.AddLetterAtPosition("c", (0,3))
    m.AddLetterAtPosition("d", (0,4))
    self.assert_(m.Valid())
    self.assert_(m.Word() == "dcba")

    m.Clear()
    self.assert_(m.Word() == None, "Word is '%s'" % m.Word())

    m.AddLetterAtPosition("a", (1,0))
    m.AddLetterAtPosition("b", (2,0))
    m.AddLetterAtPosition("c", (3,0))
    m.AddLetterAtPosition("d", (-10,0))
    self.assert_(m.Valid())
    self.assert_(m.Word() == "dabc")

  def testIteratingOverTiles(self):
    m = Move()
    num_tiles = 100
    for i in range(num_tiles):
      m.AddLetterAtPosition("a", (0,i))
    self.assert_(m.Valid())

    count = 0
    for position, letter in m:
      count += 1
    self.assert_(count == num_tiles)

  def testHorizontalExtremityTiles(self):
    m = Move()
    m.AddLetterAtPosition("A", (0,0))
    m.AddLetterAtPosition("B", (1,0))
    m.AddLetterAtPosition("C", (2,0))

    self.assert_(m.LeftmostPosition() == (0,0), repr(m.LeftmostPosition()))
    self.assert_(m.TopmostPosition() == (0,0), repr(m.TopmostPosition()))
    self.assert_(m.RightmostPosition() == (2,0), repr(m.RightmostPosition()))
    self.assert_(m.BottommostPosition() == (2,0), repr(m.BottommostPosition()))

  def testVerticalExtremityTiles(self):
    m = Move()
    m.AddLetterAtPosition("A", (0,0))
    m.AddLetterAtPosition("B", (0,1))
    m.AddLetterAtPosition("C", (0,2))

    self.assert_(m.LeftmostPosition() == (0,2))
    self.assert_(m.TopmostPosition() == (0,2))
    self.assert_(m.RightmostPosition() == (0,0))
    self.assert_(m.BottommostPosition() == (0,0))

  def testExtremitiesForSingleTileMove(self):
    m = Move()
    m.AddLetterAtPosition("A", (0,0))
    self.assert_(m.TopmostPosition() == (0,0), "Got %s." %
        repr(m.TopmostPosition()))
    self.assert_(m.BottommostPosition() == (0,0))
    self.assert_(m.LeftmostPosition() == (0,0))
    self.assert_(m.RightmostPosition() == (0,0))
    self.assert_(m.Position() == (0,0))

  def testExchangingLetters(self):
    m = Move()
    m.AddLettersToExchange("A")
    self.assert_(m.type == Move.EXCHANGE_TILES)
    self.assert_(len(m.Tiles()) == 1)

    m.Clear()
    m.AddLettersToExchange("ABCDE")
    self.assert_(m.type == Move.EXCHANGE_TILES)
    self.assert_(len(m.Tiles()) == 5)

  def testPassing(self):
    m = Move()
    m.Pass()
    self.assert_(m.type == Move.PASS)

  def testBeingDoneWithGame(self):
    m = Move()
    m.DoneWithGame()
    self.assert_(m.type == Move.DONE)

  def testTwoMoveTypesAtOnce(self):
    m = Move()
    m.DoneWithGame()
    self.assertRaises(InvalidMoveError, m.Pass)

    m.Clear()
    m.Pass()
    self.assertRaises(InvalidMoveError, m.DoneWithGame)

    m.Clear()
    m.AddLettersToExchange("A")
    self.assertRaises(InvalidMoveError, m.DoneWithGame)

    m.Clear()
    m.AddLetterAtPosition("A", (0,0))
    self.assertRaises(InvalidMoveError, m.DoneWithGame)

if __name__ == "__main__":
  unittest.main()
